Sekai Helper
Гость
→
Карта вардов
Интерактивная карта с лучшими вардами Dota 2
9 вардов
→
Фишки героев
Механики и баги
→
Связки героев
Лучшие комбо
→
Профиль
Настройки, статистика и поддержка
🚧
В разработке
Этот раздел скоро появится.
Главная
Фишки
Карта
Связки
Профиль
Sekai Helper
Загрузка
⏱ Минута:
0
0
15
30
45
60
0
Варды
Новые
Топ
👁 Топ
🏆 Вард дня
Загрузка...
Показано: 0 из 0
Статус
ADMIN
Режим: Просмотр
Включить редактирование
Вардов
0
⏱ Ползунок времени
👁 Радиусы обзора
Фильтр
Все
Observer
Sentry
Избранные
Группа
Все группы
Тема
🌙
☀️
🌓 Авто
Бэкап вардов
📥 Скачать
📤 Загрузить
Клик по фону или ✕ — закрыть
const SERVER_URL = ''; const BOOKMARK_SVG = `
`; const QUELLING_SVG = `
`; const ROSHAN_ICON = `
`; const RUNE_ICON = `
`; const tg = window.Telegram ? window.Telegram.WebApp : null; let initData = ''; let telegramUserId = null; if (tg) { if (tg.initData) initData = tg.initData; if (typeof tg.disableVerticalSwipes === 'function') tg.disableVerticalSwipes(); if (typeof tg.expand === 'function') tg.expand(); if (typeof tg.setBackgroundColor === 'function') tg.setBackgroundColor('#1a1a2e'); } if (tg.initDataUnsafe && tg.initDataUnsafe.user) { telegramUserId = tg.initDataUnsafe.user.id; const user = tg.initDataUnsafe.user; // ТЕСТ: Выводим длину initData и ID пользователя const nameEl = document.getElementById('home-user-name'); if (nameEl) { nameEl.textContent = 'ID: ' + user.id + ' | initData: ' + (initData ? initData.length + ' симв.' : 'ПУСТО'); } // Имя const userNameEl = document.getElementById('home-user-name'); if (userNameEl) { userNameEl.textContent = user.first_name || user.username || 'Гость'; } // Аватарка (ФИНАЛ) const userAvatarEl = document.getElementById('home-user-avatar'); if (userAvatarEl) { const initial = (user.first_name || user.username || 'U')[0].toUpperCase(); userAvatarEl.textContent = initial; // 1. Возвращаем имя const nameEl = document.getElementById('home-user-name'); if (nameEl) nameEl.textContent = user.first_name || user.username || 'Гость'; // 2. Грузим аватарку через fetch (передаём initData в заголовке и в URL) fetch(SERVER_URL + '/user/avatar', { method: 'GET', headers: { 'X-Init-Data': initData } }) .then(r => r.json()) .then(data => { if (data && data.url) { userAvatarEl.innerHTML = ''; const img = document.createElement('img'); img.src = data.url; img.style.width = '100%'; img.style.height = '100%'; img.style.objectFit = 'cover'; img.style.borderRadius = '50%'; userAvatarEl.appendChild(img); } }) .catch(err => console.warn('Avatar error:', err)); } } else { const userNameEl = document.getElementById('home-user-name'); if (userNameEl) userNameEl.textContent = 'Гость'; } const systemThemeQuery = window.matchMedia('(prefers-color-scheme: light)'); function getResolvedTheme(setting) { if (setting === 'system') return systemThemeQuery.matches ? 'light' : 'dark'; return setting; } function applyTheme(setting) { const resolved = getResolvedTheme(setting); document.body.setAttribute('data-theme', resolved); try { localStorage.setItem('ward_theme', setting); } catch (e) {} document.querySelectorAll('.theme-btn').forEach(b => { b.classList.toggle('active', b.dataset.theme === setting); }); if (tg && typeof tg.setHeaderColor === 'function') { tg.setHeaderColor(resolved === 'dark' ? '#1a1a2e' : '#ffffff'); } } function loadThemeSetting() { try { const saved = localStorage.getItem('ward_theme'); if (saved === 'light' || saved === 'dark' || saved === 'system') return saved; } catch (e) {} return 'system'; } applyTheme(loadThemeSetting()); document.querySelectorAll('.theme-btn').forEach(btn => { btn.addEventListener('click', function() { applyTheme(this.dataset.theme); }); }); systemThemeQuery.addEventListener('change', function() { try { const saved = localStorage.getItem('ward_theme'); if (saved === 'system') applyTheme('system'); } catch (e) {} }); const MAP_WIDTH = 2048; const MAP_HEIGHT = 2048; const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]; function isMobile() { return window.innerWidth < 768; } function getMinZoom() { return isMobile() ? -3 : -5; } function getStartZoom() { return isMobile() ? -2 : -1; } const map = L.map('map', { crs: L.CRS.Simple, minZoom: getMinZoom(), maxZoom: 2, zoomControl: false, attributionControl: false, maxBounds: bounds, maxBoundsViscosity: 1.0, tap: false, inertia: false, fadeAnimation: false, markerZoomAnimation: false }); const mapImage = 'map.jpg'; L.imageOverlay(mapImage, bounds).addTo(map); map.fitBounds(bounds); map.setZoom(getStartZoom()); window.map = map; setTimeout(() => { const overlay = document.getElementById('loading-overlay'); if (overlay) overlay.classList.add('hidden'); }, 600); window.addEventListener('resize', function() { map.setMinZoom(getMinZoom()); }); const NO_IMAGE = 'data:image/svg+xml;utf8,
Скриншот не добавлен
'; const WARD_ICONS = { observer: `
`, sentry: `
`, roshan: ROSHAN_ICON, rune: RUNE_ICON }; const VISUAL_ONLY_TYPES = ['roshan', 'rune']; let editMode = false, isAdmin = false, wards = [], wardMarkers = {}, currentFilter = 'all', searchQuery = ''; let currentSort = 'date'; let currentGroup = ''; let currentWardId = null; let isClosing = false; let listScrollPosition = 0; let viewedWards = new Set(); try { const saved = localStorage.getItem('viewed_wards'); if (saved) viewedWards = new Set(JSON.parse(saved)); } catch (e) {} // ===== ПОЛЗУНОК ВРЕМЕНИ ===== let timeSliderEnabled = false; let currentMinute = 0; try { const saved = localStorage.getItem('time_slider_enabled'); if (saved === 'true') timeSliderEnabled = true; } catch (e) {} const timeSliderContainer = document.getElementById('time-slider-container'); const timeSliderToggle = document.getElementById('time-slider-toggle'); const timeSliderInput = document.getElementById('time-slider-input'); const timeSliderValue = document.getElementById('time-slider-value'); const timeSliderMarks = document.getElementById('time-slider-marks'); const timeSliderClose = document.getElementById('time-slider-close'); timeSliderToggle.checked = timeSliderEnabled; if (timeSliderEnabled) { setTimeout(() => { timeSliderContainer.classList.add('open'); }, 400); } function toggleTimeSlider(enabled) { timeSliderEnabled = enabled; try { localStorage.setItem('time_slider_enabled', enabled); } catch (e) {} if (enabled) { timeSliderContainer.classList.add('open'); renderWards(); } else { timeSliderContainer.classList.remove('open'); renderWards(); } } timeSliderToggle.addEventListener('change', function() { toggleTimeSlider(this.checked); }); timeSliderClose.addEventListener('click', function() { timeSliderToggle.checked = false; toggleTimeSlider(false); }); timeSliderInput.addEventListener('input', function() { currentMinute = parseInt(this.value); timeSliderValue.textContent = currentMinute; updateSliderMarks(); renderWards(); }); function updateSliderMarks() { timeSliderMarks.innerHTML = ''; const wardMinutes = new Set(); wards.forEach(w => { if (w.minute !== null && w.minute !== undefined && w.minute !== '' && !isVisualOnly(w)) { wardMinutes.add(parseInt(w.minute)); } }); const grouped = []; const sorted = Array.from(wardMinutes).sort((a,b) => a-b); sorted.forEach(m => { const last = grouped[grouped.length - 1]; if (last === undefined || m - last > 2) { grouped.push(m); } }); grouped.forEach(m => { const percent = (m / 60) * 100; const mark = document.createElement('div'); mark.className = 'time-mark'; mark.style.left = percent + '%'; if (m === currentMinute) { mark.classList.add('active'); } timeSliderMarks.appendChild(mark); }); } // ===== РАДИУСЫ ОБЗОРА ===== let radiusEnabled = false; let radiusCircles = []; try { const saved = localStorage.getItem('radius_enabled'); if (saved === 'true') radiusEnabled = true; } catch (e) {} const radiusToggle = document.getElementById('radius-toggle'); radiusToggle.checked = radiusEnabled; const DOTA_MAP_SIZE = 16000; const PIXEL_RATIO = MAP_WIDTH / DOTA_MAP_SIZE; const RADIUS_SCALE = 0.85; const OBSERVER_RADIUS = 1600 * PIXEL_RATIO * RADIUS_SCALE; const SENTRY_RADIUS = 1000 * PIXEL_RATIO * RADIUS_SCALE; function clearRadiusCircles() { radiusCircles.forEach(c => map.removeLayer(c)); radiusCircles = []; } function drawRadiusCircles() { clearRadiusCircles(); if (!radiusEnabled) return; const visibleWards = getFilteredWards(); visibleWards.forEach(ward => { const type = getWardType(ward); if (isVisualOnly(ward)) return; let radius, color; if (type === 'observer') { radius = OBSERVER_RADIUS; color = '#f39c12'; } else if (type === 'sentry') { radius = SENTRY_RADIUS; color = '#4a9eff'; } else { return; } const circle = L.circle([ward.y, ward.x], { radius: radius, color: color, fillColor: color, fillOpacity: 0.08, weight: 1.5, opacity: 0.5, interactive: false }).addTo(map); radiusCircles.push(circle); }); } radiusToggle.addEventListener('change', function() { radiusEnabled = this.checked; try { localStorage.setItem('radius_enabled', radiusEnabled); } catch (e) {} drawRadiusCircles(); }); // ===== LIGHTBOX ===== const lightbox = document.getElementById('lightbox'); const lightboxImg = document.getElementById('lightbox-img'); const lightboxClose = document.getElementById('lightbox-close'); let lightboxClosing = false; function openLightbox(src) { lightboxClosing = false; lightboxImg.src = src; lightbox.classList.remove('closing'); lightbox.classList.add('open'); } function closeLightbox() { if (lightboxClosing) return; lightboxClosing = true; lightbox.classList.add('closing'); setTimeout(() => { lightbox.classList.remove('open', 'closing'); lightboxImg.src = ''; lightboxClosing = false; }, 250); } lightboxClose.addEventListener('click', function(e) { e.stopPropagation(); closeLightbox(); }); lightbox.addEventListener('click', function(e) { if (e.target === lightbox || e.target === lightboxImg) closeLightbox(); }); // ===== МОДАЛКА ===== const modalOverlay = document.getElementById('modal-overlay'); const modalTitle = document.getElementById('modal-title'); const modalBody = document.getElementById('modal-body'); const modalClose = document.getElementById('modal-close'); function openModal() { isClosing = false; modalOverlay.classList.remove('closing'); modalOverlay.classList.add('open'); } function closeModal() { if (isClosing) return; isClosing = true; modalOverlay.classList.add('closing'); setTimeout(() => { modalOverlay.classList.remove('open', 'closing'); isClosing = false; currentWardId = null; }, 250); } modalClose.addEventListener('click', closeModal); modalOverlay.addEventListener('click', function(e) { if (e.target === modalOverlay) closeModal(); }); // ===== UI ===== const listBtn = document.getElementById('list-btn'); const listPanel = document.getElementById('list-panel'); const listClose = document.getElementById('list-close'); const listItems = document.getElementById('list-items'); const listSearch = document.getElementById('list-search'); const listCounter = document.getElementById('list-counter'); const listBadge = document.getElementById('list-badge'); listBtn.addEventListener('click', function(e) { e.stopPropagation(); listPanel.classList.toggle('open'); listBtn.classList.toggle('open'); renderList(); if (listPanel.classList.contains('open')) { setTimeout(() => { listItems.scrollTop = listScrollPosition; }, 50); } }); listClose.addEventListener('click', function(e) { e.stopPropagation(); listScrollPosition = listItems.scrollTop; listPanel.classList.remove('open'); listBtn.classList.remove('open'); }); listSearch.addEventListener('input', function() { searchQuery = this.value.trim().toLowerCase(); renderList(); }); document.querySelectorAll('.sort-btn').forEach(btn => { btn.addEventListener('click', function() { document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active')); this.classList.add('active'); currentSort = this.dataset.sort; renderList(); }); }); const settingsBtn = document.getElementById('settings-btn'); const settingsPanel = document.getElementById('settings-panel'); settingsBtn.addEventListener('click', function(e) { e.stopPropagation(); settingsPanel.classList.toggle('open'); settingsBtn.classList.toggle('open'); }); document.addEventListener('click', function(e) { if (listPanel.classList.contains('open')) { if (!listPanel.contains(e.target) && !listBtn.contains(e.target)) { listScrollPosition = listItems.scrollTop; listPanel.classList.remove('open'); listBtn.classList.remove('open'); } } if (settingsPanel.classList.contains('open')) { if (!settingsPanel.contains(e.target) && !settingsBtn.contains(e.target)) { settingsPanel.classList.remove('open'); settingsBtn.classList.remove('open'); } } }); document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { if (lightbox.classList.contains('open')) closeLightbox(); else if (modalOverlay.classList.contains('open')) closeModal(); else { listPanel.classList.remove('open'); listBtn.classList.remove('open'); settingsPanel.classList.remove('open'); settingsBtn.classList.remove('open'); } } }); document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => { btn.addEventListener('click', function() { document.querySelectorAll('.filter-btn[data-filter]').forEach(b => b.classList.remove('active')); this.classList.add('active'); currentFilter = this.dataset.filter; renderWards(); renderList(); }); }); const groupFilterSelect = document.getElementById('group-filter'); if (groupFilterSelect) { groupFilterSelect.addEventListener('change', function() { currentGroup = this.value; renderWards(); renderList(); }); } function authHeaders() { const h = { 'Content-Type': 'application/json' }; if (initData) h['X-Init-Data'] = initData; const urlParams = new URLSearchParams(window.location.search); const adminParam = urlParams.get('admin'); if (adminParam) h['X-Admin-Secret'] = adminParam; return h; } async function apiGetWards() { const r = await fetch(SERVER_URL + '/wards?_=' + Date.now(), { cache: 'no-store', headers: authHeaders() }); if (!r.ok) throw new Error('Ошибка загрузки: ' + r.status); return await r.json(); } async function apiGetMe() { const r = await fetch(SERVER_URL + '/me', { headers: authHeaders() }); if (!r.ok) throw new Error('Ошибка /me: ' + r.status); return await r.json(); } async function apiCreateWard(ward) { const r = await fetch(SERVER_URL + '/wards', { method: 'POST', headers: authHeaders(), body: JSON.stringify(ward) }); if (!r.ok) throw new Error('Ошибка создания: ' + r.status); return await r.json(); } async function apiUpdateWard(id, ward) { const r = await fetch(SERVER_URL + '/wards/' + id, { method: 'PUT', headers: authHeaders(), body: JSON.stringify(ward) }); if (!r.ok) throw new Error('Ошибка обновления: ' + r.status); return await r.json(); } async function apiDeleteWard(id) { const r = await fetch(SERVER_URL + '/wards/' + id, { method: 'DELETE', headers: authHeaders() }); if (!r.ok) throw new Error('Ошибка удаления: ' + r.status); return await r.json(); } async function apiLikeWard(id) { const r = await fetch(SERVER_URL + '/wards/' + id + '/like', { method: 'POST', headers: authHeaders() }); if (!r.ok) throw new Error('Ошибка лайка: ' + r.status); return await r.json(); } async function apiViewWard(id) { try { await fetch(SERVER_URL + '/wards/' + id + '/view', { method: 'POST', headers: authHeaders() }); } catch (e) {} } async function apiGetComments(wardId) { const r = await fetch(SERVER_URL + '/wards/' + wardId + '/comments?_=' + Date.now(), { cache: 'no-store', headers: authHeaders() }); if (!r.ok) throw new Error('Ошибка загрузки комментариев: ' + r.status); return await r.json(); } async function apiCreateComment(wardId, text) { const r = await fetch(SERVER_URL + '/wards/' + wardId + '/comments', { method: 'POST', headers: authHeaders(), body: JSON.stringify({ text: text }) }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || ('Ошибка ' + r.status)); } return await r.json(); } async function apiDeleteComment(commentId) { const r = await fetch(SERVER_URL + '/comments/delete?id=' + commentId, { method: 'GET', headers: authHeaders() }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || ('Ошибка ' + r.status)); } return await r.json(); } let wardOfTheDay = null; async function loadWardOfTheDay() { try { const r = await fetch(SERVER_URL + '/ward-of-the-day?_=' + Date.now(), { cache: 'no-store' }); if (!r.ok) return; const ward = await r.json(); if (!ward || !ward.id) { document.getElementById('ward-of-the-day').style.display = 'none'; return; } wardOfTheDay = ward; const likes = (ward.likes || []).length; const views = ward.views || 0; document.getElementById('wotd-title').textContent = ward.title || 'Без названия'; document.getElementById('wotd-stats').textContent = `❤️ ${likes} · 👁 ${views}`; document.getElementById('ward-of-the-day').style.display = 'block'; } catch (e) { console.error('loadWardOfTheDay error:', e); document.getElementById('ward-of-the-day').style.display = 'none'; } } window.openWardOfTheDay = function() { if (wardOfTheDay && wardOfTheDay.id) { goToWard(wardOfTheDay.id); } }; async function loadGroups() { try { const r = await fetch(SERVER_URL + '/groups'); if (!r.ok) return; const groups = await r.json(); const select = document.getElementById('group-filter'); const datalist = document.getElementById('group-list'); if (select) { select.innerHTML = '
Все группы
' + groups.map(g => `
${escapeHtml(g)}
`).join(''); } if (datalist) { datalist.innerHTML = groups.map(g => `
`).join(''); } const row = document.getElementById('group-filter-row'); if (row) { row.style.display = groups.length > 0 ? 'block' : 'none'; } } catch (e) { console.error('loadGroups error:', e); } } async function checkAdmin() { try { const me = await apiGetMe(); isAdmin = me.authorized && me.is_admin; if (isAdmin) { document.getElementById('row-admin').style.display = 'flex'; document.getElementById('row-toggle').style.display = 'flex'; document.getElementById('row-backup').style.display = 'block'; } } catch (e) { console.error(e); isAdmin = false; } } const modeToggle = document.getElementById('mode-toggle-settings'); const modeLabel = document.getElementById('mode-label-settings'); modeToggle.addEventListener('click', function() { if (!isAdmin) return; editMode = !editMode; if (editMode) { modeLabel.textContent = 'Режим: Редактирование'; modeToggle.textContent = 'Выключить'; modeToggle.classList.add('active'); } else { modeLabel.textContent = 'Режим: Просмотр'; modeToggle.textContent = 'Включить редактирование'; modeToggle.classList.remove('active'); } }); const exportBtn = document.getElementById('export-btn'); const importBtn = document.getElementById('import-btn'); const importInput = document.getElementById('import-input'); if (exportBtn) { exportBtn.addEventListener('click', function() { const urlParams = new URLSearchParams(window.location.search); const adminParam = urlParams.get('admin'); const url = SERVER_URL + '/export' + (adminParam ? '?admin=' + encodeURIComponent(adminParam) : ''); window.open(url, '_blank'); }); } if (importBtn) { importBtn.addEventListener('click', function() { if (!confirm('Загрузить бэкап? Все текущие варды будут заменены.')) return; importInput.click(); }); } if (importInput) { importInput.addEventListener('change', async function() { const file = this.files && this.files[0]; if (!file) return; try { const formData = new FormData(); formData.append('file', file); const headers = {}; if (initData) headers['X-Init-Data'] = initData; const urlParams = new URLSearchParams(window.location.search); const adminParam = urlParams.get('admin'); if (adminParam) headers['X-Admin-Secret'] = adminParam; const r = await fetch(SERVER_URL + '/import', { method: 'POST', headers: headers, body: formData }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || ('Ошибка ' + r.status)); } const data = await r.json(); alert('✓ Импортировано вардов: ' + data.count); await refreshWards(); } catch (e) { alert('✗ Ошибка импорта: ' + e.message); } finally { this.value = ''; } }); } async function refreshWards() { try { wards = await apiGetWards(); const wardCount = wards.filter(w => !isVisualOnly(w)).length; document.getElementById('wards-count').textContent = wardCount; listBadge.textContent = wardCount; updateSliderMarks(); renderWards(); if (radiusEnabled) drawRadiusCircles(); renderList(); } catch (e) { console.error(e); } } function detectVideoType(url) { if (!url || !url.trim()) return null; const u = url.trim().toLowerCase(); if (u.endsWith('.mp4') || u.endsWith('.webm') || u.endsWith('.ogg') || u.endsWith('.mov')) return 'video'; return 'iframe'; } function hasRealImage(ward) { return ward.image && ward.image !== NO_IMAGE && ward.image.trim() !== ''; } function hasRealVideo(ward) { return ward.video && ward.video.trim() !== ''; } function hasRealDescription(ward) { return ward.description && ward.description.trim() !== ''; } function getWardType(ward) { const t = ward.ward_type || 'observer'; if (t === 'sentry' || t === 'roshan' || t === 'rune') return t; return 'observer'; } function isVisualOnly(ward) { return VISUAL_ONLY_TYPES.includes(getWardType(ward)); } function getFilteredWards() { let result = wards; if (currentFilter === 'observer') result = result.filter(w => getWardType(w) === 'observer'); else if (currentFilter === 'sentry') result = result.filter(w => getWardType(w) === 'sentry'); else if (currentFilter === 'favorites') { result = result.filter(w => telegramUserId && (w.likes || []).includes(telegramUserId)); } if (currentGroup) { result = result.filter(w => (w.group || '') === currentGroup); } if (timeSliderEnabled) { result = result.filter(w => { if (isVisualOnly(w)) return false; if (w.minute === null || w.minute === undefined || w.minute === '') return false; return Math.abs(parseInt(w.minute) - currentMinute) <= 2; }); } return result; } function resolveSrc(value) { if (!value) return value; if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:') || value.startsWith('/')) return value; return '/uploads/' + value; } function renderWards() { Object.values(wardMarkers).forEach(m => map.removeLayer(m)); wardMarkers = {}; const visibleWards = getFilteredWards(); visibleWards.forEach(ward => { const type = getWardType(ward); const isSmall = VISUAL_ONLY_TYPES.includes(type); let iconHtml = `
${WARD_ICONS[type]}`; if (ward.needs_quelling && !isSmall) { iconHtml += `
${QUELLING_SVG}
`; } iconHtml += `
`; const size = isSmall ? 24 : 36; const icon = L.divIcon({ className: 'ward-icon-marker', html: iconHtml, iconSize: [size, size], iconAnchor: [size/2, size/2] }); const marker = L.marker([ward.y, ward.x], { icon: icon }).addTo(map); if (!isSmall || (editMode && isAdmin)) { marker.on('click', function() { openWardModal(ward.id); }); } else { marker.on('click', function(e) { L.DomEvent.stopPropagation(e); }); } wardMarkers[ward.id] = marker; }); if (radiusEnabled) { drawRadiusCircles(); } } function openWardModal(id) { const ward = wards.find(w => w.id === id); if (!ward) return; currentWardId = id; viewedWards.add(id); try { localStorage.setItem('viewed_wards', JSON.stringify([...viewedWards])); } catch (e) {} apiViewWard(id); ward.views = (ward.views || 0) + 1; const type = getWardType(ward); let titleHtml = `
${escapeHtml(ward.title)}`; if (ward.needs_quelling) { titleHtml += `
${QUELLING_SVG}
`; } if (ward.minute !== null && ward.minute !== undefined && ward.minute !== '') { titleHtml += `
⏱ ${ward.minute} мин
`; } modalTitle.innerHTML = titleHtml; let html = ''; if (hasRealDescription(ward)) { html += `
Описание
`; html += `
${escapeHtml(ward.description)}
`; } if (hasRealImage(ward)) { const src = resolveSrc(ward.image); html += `
Скриншот
`; html += `
`; } if (hasRealVideo(ward)) { const src = resolveSrc(ward.video); const vtype = detectVideoType(ward.video); html += `
Видео
`; if (vtype === 'video') { html += `
Ваш браузер не поддерживает видео.
Скачать
`; } else { html += `
`; } } const views = ward.views || 0; const likesCount = (ward.likes || []).length; const isLiked = telegramUserId && (ward.likes || []).includes(telegramUserId); const isSecretAdmin = new URLSearchParams(window.location.search).get('admin'); html += `
👁 ${views}
${BOOKMARK_SVG.replace('
`; if (initData || isSecretAdmin) { html += `
${BOOKMARK_SVG.replace('
`; } html += `
📤 Поделиться
`; if (editMode && isAdmin) { html += `
Редактировать
Удалить
`; } // ===== БЛОК КОММЕНТАРИЕВ ===== html += `
💬 Комментарии
Загрузка...
${initData || isSecretAdmin ? `
Отправить
` : `
Войдите через Telegram, чтобы оставить комментарий
`}
`; modalBody.innerHTML = html; openModal(); // Загружаем комментарии после открытия модалки loadComments(ward.id); } window.loadComments = async function(wardId) { const container = document.getElementById('comments-list'); if (!container) return; try { const comments = await apiGetComments(wardId); if (!comments || comments.length === 0) { container.innerHTML = `
Пока нет комментариев. Будь первым!
`; return; } const isSecretAdmin = new URLSearchParams(window.location.search).get('admin'); container.innerHTML = comments.map(c => { const canDelete = isSecretAdmin || (telegramUserId && telegramUserId === c.author_id); const date = new Date(c.date).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }); return `
${escapeHtml(c.author_name)}
${date}
${escapeHtml(c.text)}
${canDelete ? `
✕
` : ''}
`; }).join(''); } catch (e) { container.innerHTML = `
Ошибка загрузки комментариев
`; console.error(e); } }; window.submitComment = async function(wardId) { const input = document.getElementById('comment-input'); if (!input) return; const text = input.value.trim(); if (!text) return; const btn = document.querySelector('.comment-submit'); if (btn) { btn.disabled = true; btn.textContent = '...'; } try { await apiCreateComment(wardId, text); input.value = ''; await loadComments(wardId); } catch (e) { alert('Не удалось отправить: ' + e.message); } finally { if (btn) { btn.disabled = false; btn.textContent = 'Отправить'; } } }; window.deleteComment = async function(commentId, wardId) { if (!confirm('Удалить этот комментарий?')) return; try { await apiDeleteComment(commentId); await loadComments(wardId); } catch (e) { alert('Не удалось удалить: ' + e.message); } }; window.likeWard = async function(id) { try { const result = await apiLikeWard(id); const ward = wards.find(w => w.id === id); if (ward && result.likes !== undefined) { if (!ward.likes) ward.likes = []; if (result.liked) { if (telegramUserId && !ward.likes.includes(telegramUserId)) { ward.likes.push(telegramUserId); } } else { if (telegramUserId) { ward.likes = ward.likes.filter(uid => uid !== telegramUserId); } } } openWardModal(id); renderList(); } catch (e) { console.error('Ошибка лайка:', e); } }; window.openLightbox = openLightbox; window.openEditModal = function(id) { if (!isAdmin) return; const ward = wards.find(w => w.id === id); if (!ward) return; currentWardId = id; const wardType = getWardType(ward); const imageValue = hasRealImage(ward) ? ward.image : ''; const needsQuelling = ward.needs_quelling || false; const minuteValue = (ward.minute !== null && ward.minute !== undefined) ? ward.minute : ''; modalTitle.innerHTML = `Редактирование`; const html = `
Тип маркера
Observer
Sentry
Рошан
Руна
Название
Описание
${escapeHtml(ward.description || '')}
⏱ Минута игры (0-60, опционально)
Оставь пустым, если вард актуален всегда
Группа
Оставь пустым, если группа не нужна
Дополнительно
🪓 Нужен топорик
Скриншот
📎 Файл
Видео
📎 Файл
Сохранить
Отмена
`; modalBody.innerHTML = html; }; window.selectWardType = function(el, type) { document.querySelectorAll('.ward-type-option').forEach(o => o.classList.remove('active')); el.classList.add('active'); document.getElementById('edit-type').value = type; }; window.uploadFile = function(kind) { const input = document.getElementById('upload-' + kind + '-input'); if (input) input.click(); }; window.handleFileSelected = async function(input, kind) { const file = input.files && input.files[0]; if (!file) return; const btn = document.getElementById('upload-' + kind + '-btn'); const progress = document.getElementById('upload-' + kind + '-progress'); const field = document.getElementById('edit-' + kind); btn.disabled = true; btn.classList.add('uploading'); btn.innerHTML = '
Загрузка...'; progress.textContent = ''; progress.className = 'upload-progress'; try { const formData = new FormData(); formData.append('file', file); const headers = {}; if (initData) headers['X-Init-Data'] = initData; const urlParams = new URLSearchParams(window.location.search); const adminParam = urlParams.get('admin'); if (adminParam) headers['X-Admin-Secret'] = adminParam; const r = await fetch(SERVER_URL + '/upload', { method: 'POST', headers: headers, body: formData }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || ('Ошибка ' + r.status)); } const data = await r.json(); field.value = data.filename; const sizeMb = (data.size / 1024 / 1024).toFixed(2); progress.textContent = '✓ Загружено: ' + data.filename + ' (' + sizeMb + ' МБ)'; progress.className = 'upload-progress success'; btn.innerHTML = '✓ Готово'; setTimeout(() => { btn.innerHTML = '📎 Файл'; btn.disabled = false; btn.classList.remove('uploading'); }, 1500); } catch (e) { console.error('Ошибка загрузки:', e); progress.textContent = '✗ ' + e.message; progress.className = 'upload-progress error'; btn.innerHTML = '📎 Файл'; btn.disabled = false; btn.classList.remove('uploading'); } finally { input.value = ''; } }; window.saveWardEdit = async function(id) { if (!isAdmin) return; const ward = wards.find(w => w.id === id); if (!ward) return; const needsQuelling = document.getElementById('edit-quelling').checked; const minuteRaw = document.getElementById('edit-minute').value.trim(); let minute = null; if (minuteRaw !== '') { const m = parseInt(minuteRaw); if (!isNaN(m) && m >= 0 && m <= 60) { minute = m; } } const updated = { id: ward.id, x: ward.x, y: ward.y, title: document.getElementById('edit-title').value || 'Без названия', description: document.getElementById('edit-description').value, image: document.getElementById('edit-image').value.trim() || NO_IMAGE, video: document.getElementById('edit-video').value.trim(), ward_type: document.getElementById('edit-type').value || 'observer', needs_quelling: needsQuelling, views: ward.views || 0, likes: ward.likes || [], minute: minute, group: document.getElementById('edit-group').value.trim() }; try { await apiUpdateWard(id, updated); await refreshWards(); setTimeout(() => { openWardModal(id); }, 100); } catch (e) { alert('Не удалось сохранить: ' + e.message); } }; window.deleteWard = async function(id) { if (!isAdmin) return; if (!confirm('Удалить этот маркер?')) return; try { await apiDeleteWard(id); await refreshWards(); closeModal(); } catch (e) { alert('Не удалось удалить: ' + e.message); } }; window.shareWard = function(id) { const ward = wards.find(w => w.id === id); if (!ward) return; if (tg && typeof tg.switchInlineQuery === 'function') { const query = 'ward_' + id; try { tg.switchInlineQuery(query, ['users', 'groups', 'channels']); } catch (e) { console.error('switchInlineQuery error:', e); copyToClipboard(`https://t.me/sekai_dota_bot?start=ward_${id}`); } return; } copyToClipboard(`https://t.me/sekai_dota_bot?start=ward_${id}`); }; function copyToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => { showToast('✓ Ссылка скопирована в буфер обмена'); }).catch(() => fallbackCopy(text)); } else { fallbackCopy(text); } } function fallbackCopy(text) { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); try { document.execCommand('copy'); showToast('✓ Ссылка скопирована'); } catch (e) { showToast('⚠ Скопируйте вручную: ' + text); } document.body.removeChild(textarea); } function showToast(message) { const toast = document.createElement('div'); toast.textContent = message; toast.style.cssText = ` position: fixed; bottom: 100px; left: 50%; transform: translateX(-50%); background: rgba(40, 40, 55, 0.95); color: #fff; padding: 12px 20px; border-radius: 25px; font-size: 14px; font-weight: 600; z-index: 5000; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); animation: toastIn 0.3s ease; pointer-events: none; max-width: 90vw; text-align: center; `; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; setTimeout(() => toast.remove(), 300); }, 2500); } function renderList() { let filtered = getFilteredWards().filter(w => !isVisualOnly(w)); let visible = filtered; if (searchQuery) { visible = filtered.filter(w => (w.title || '').toLowerCase().includes(searchQuery) || (w.description || '').toLowerCase().includes(searchQuery) ); } if (currentSort === 'likes') { visible = [...visible].sort((a, b) => ((b.likes || []).length) - ((a.likes || []).length)); } else if (currentSort === 'views') { visible = [...visible].sort((a, b) => (b.views || 0) - (a.views || 0)); } else { visible = [...visible].sort((a, b) => (b.id || 0) - (a.id || 0)); } const totalWards = wards.filter(w => !isVisualOnly(w)).length; listCounter.textContent = `Показано: ${visible.length} из ${totalWards}`; if (visible.length === 0) { listItems.innerHTML = `
Ничего не найдено
`; return; } listItems.innerHTML = visible.map(ward => { const type = getWardType(ward); const likesCount = (ward.likes || []).length; const viewsCount = ward.views || 0; const isViewed = viewedWards.has(ward.id); let iconHtml = `${WARD_ICONS[type]}`; let statsHtml = ''; if (likesCount > 0) { statsHtml += `
${BOOKMARK_SVG.replace('
`; } if (viewsCount > 0) { statsHtml += `
👁 ${viewsCount}
`; } let minuteHtml = ''; if (ward.minute !== null && ward.minute !== undefined && ward.minute !== '') { minuteHtml = `
⏱${ward.minute}
`; } return `
${iconHtml}
${escapeHtml(ward.title || 'Без названия')}${ward.needs_quelling ? ' 🪓' : ''}${minuteHtml}
${statsHtml}
${type === 'observer' ? 'OBS' : 'SEN'}
`; }).join(''); } window.goToWard = function(id) { const ward = wards.find(w => w.id === id); if (!ward) return; map.setView([ward.y, ward.x], Math.max(map.getZoom(), 0), { animate: true }); openWardModal(id); if (window.innerWidth < 768) { listScrollPosition = listItems.scrollTop; listPanel.classList.remove('open'); listBtn.classList.remove('open'); } }; map.on('click', async function(e) { if (!editMode || !isAdmin) return; const x = Math.round(e.latlng.lng); const y = Math.round(e.latlng.lat); const newWard = { id: Date.now(), x: x, y: y, title: 'Новый маркер', description: '', image: NO_IMAGE, video: '', ward_type: 'observer', needs_quelling: false, views: 0, likes: [], minute: null }; try { await apiCreateWard(newWard); await refreshWards(); setTimeout(() => { openEditModal(newWard.id); }, 100); } catch (e) { alert('Не удалось создать: ' + e.message); } }); function escapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function escapeAttr(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); } function switchPage(pageName) { // Скрываем все страницы document.querySelectorAll('#page-home, #page-map, #page-placeholder').forEach(el => { el.classList.remove('active'); }); // Показываем нужную if (pageName === 'home') { document.getElementById('page-home').classList.add('active'); } else if (pageName === 'map') { document.getElementById('page-map').classList.add('active'); // Пересчитываем размер карты setTimeout(() => { if (window.map) window.map.invalidateSize(); }, 100); } else { // heroes, synergy, profile — заглушка const placeholder = document.getElementById('page-placeholder'); const titles = { heroes: { icon: '💡', title: 'Фишки героев', desc: 'Скоро здесь появятся механики, баги и полезные приёмы для каждого героя.' }, synergy: { icon: '🤝', title: 'Связки героев', desc: 'Скоро здесь появятся лучшие комбинации героев для победы.' }, profile: { icon: '👤', title: 'Профиль', desc: 'Скоро здесь появятся настройки, статистика и поддержка.' } }; const info = titles[pageName] || { icon: '🚧', title: 'В разработке', desc: 'Этот раздел скоро появится.' }; document.getElementById('placeholder-icon').textContent = info.icon; document.getElementById('placeholder-title').textContent = info.title; document.getElementById('placeholder-desc').textContent = info.desc; placeholder.classList.add('active'); } // Обновляем активную кнопку в нижней панели document.querySelectorAll('.nav-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.page === pageName); }); } window.switchPage = switchPage; document.querySelectorAll('.nav-btn, .home-card').forEach(btn => { btn.style.position = 'relative'; btn.style.overflow = 'hidden'; btn.addEventListener('click', function(e) { const rect = this.getBoundingClientRect(); const size = Math.max(rect.width, rect.height) * 2; const x = e.clientX - rect.left - size / 2; const y = e.clientY - rect.top - size / 2; const ripple = document.createElement('span'); ripple.className = 'ripple-effect'; ripple.style.width = ripple.style.height = size + 'px'; ripple.style.left = x + 'px'; ripple.style.top = y + 'px'; this.appendChild(ripple); setTimeout(() => ripple.remove(), 600); }); }); (async function init() { switchPage('home'); await checkAdmin(); await refreshWards(); await loadGroups(); await loadWardOfTheDay(); })();